import { Form, Col, Row, Button, FormControl } from "react-bootstrap"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { GallerySelect, Icon, LoadingIndicator, Modal, SceneSelect, StringListSelect, } from "src/components/Shared"; import { FormUtils, ImageUtils, TextUtils } from "src/utils"; import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { hasScrapedValues, ScrapeDialog, ScrapeDialogRow, ScrapedImageRow, ScrapedInputGroupRow, ScrapedTextAreaRow, ScrapeResult, } from "../Shared/ScrapeDialog"; import { clone, uniq } from "lodash-es"; import { ScrapedMoviesRow, ScrapedPerformersRow, ScrapedStudioRow, ScrapedTagsRow, } from "./SceneDetails/SceneScrapeDialog"; import { galleryTitle } from "src/core/galleries"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; interface IStashIDsField { values: GQL.StashId[]; } const StashIDsField: React.FC = ({ values }) => { return v.stash_id)} />; }; interface ISceneMergeDetailsProps { sources: GQL.SlimSceneDataFragment[]; dest: GQL.SlimSceneDataFragment; onClose: (values?: GQL.SceneUpdateInput) => void; } const SceneMergeDetails: React.FC = ({ sources, dest, onClose, }) => { const intl = useIntl(); const [loading, setLoading] = useState(true); const [title, setTitle] = useState>( new ScrapeResult(dest.title) ); const [url, setURL] = useState>( new ScrapeResult(dest.url) ); const [date, setDate] = useState>( new ScrapeResult(dest.date) ); const [rating, setRating] = useState( new ScrapeResult(dest.rating100) ); const [oCounter, setOCounter] = useState( new ScrapeResult(dest.o_counter) ); const [playCount, setPlayCount] = useState( new ScrapeResult(dest.play_count) ); const [playDuration, setPlayDuration] = useState( new ScrapeResult(dest.play_duration) ); const [studio, setStudio] = useState>( new ScrapeResult(dest.studio?.id) ); function sortIdList(idList?: string[] | null) { if (!idList) { return; } const ret = clone(idList); // sort by id numerically ret.sort((a, b) => { return parseInt(a, 10) - parseInt(b, 10); }); return ret; } const [performers, setPerformers] = useState>( new ScrapeResult(sortIdList(dest.performers.map((p) => p.id))) ); const [movies, setMovies] = useState>( new ScrapeResult(sortIdList(dest.movies.map((p) => p.movie.id))) ); const [tags, setTags] = useState>( new ScrapeResult(sortIdList(dest.tags.map((t) => t.id))) ); const [details, setDetails] = useState>( new ScrapeResult(dest.details) ); const [galleries, setGalleries] = useState>( new ScrapeResult(sortIdList(dest.galleries.map((p) => p.id))) ); const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); const [image, setImage] = useState>( new ScrapeResult(dest.paths.screenshot) ); // calculate the values for everything // uses the first set value for single value fields, and combines all useEffect(() => { async function loadImages() { const src = sources.find((s) => s.paths.screenshot); if (!dest.paths.screenshot || !src) return; setLoading(true); const destData = await ImageUtils.imageToDataURL(dest.paths.screenshot); const srcData = await ImageUtils.imageToDataURL(src.paths!.screenshot!); // keep destination image by default const useNewValue = false; setImage(new ScrapeResult(destData, srcData, useNewValue)); setLoading(false); } // append dest to all so that if dest has stash_ids with the same // endpoint, then it will be excluded first const all = sources.concat(dest); setTitle( new ScrapeResult( dest.title, sources.find((s) => s.title)?.title, !dest.title ) ); setURL( new ScrapeResult(dest.url, sources.find((s) => s.url)?.url, !dest.url) ); setDate( new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date) ); setStudio( new ScrapeResult( dest.studio?.id, sources.find((s) => s.studio)?.studio?.id, !dest.studio ) ); setPerformers( new ScrapeResult( dest.performers.map((p) => p.id), uniq(all.map((s) => s.performers.map((p) => p.id)).flat()) ) ); setTags( new ScrapeResult( dest.tags.map((p) => p.id), uniq(all.map((s) => s.tags.map((p) => p.id)).flat()) ) ); setDetails( new ScrapeResult( dest.details, sources.find((s) => s.details)?.details, !dest.details ) ); setMovies( new ScrapeResult( dest.movies.map((m) => m.movie.id), uniq(all.map((s) => s.movies.map((m) => m.movie.id)).flat()) ) ); setGalleries( new ScrapeResult( dest.galleries.map((p) => p.id), uniq(all.map((s) => s.galleries.map((p) => p.id)).flat()) ) ); setRating( new ScrapeResult( dest.rating100, sources.find((s) => s.rating100)?.rating100, !dest.rating100 ) ); setOCounter( new ScrapeResult( dest.o_counter ?? 0, all.map((s) => s.o_counter ?? 0).reduce((pv, cv) => pv + cv, 0) ) ); setPlayCount( new ScrapeResult( dest.play_count ?? 0, all.map((s) => s.play_count ?? 0).reduce((pv, cv) => pv + cv, 0) ) ); setPlayDuration( new ScrapeResult( dest.play_duration ?? 0, all.map((s) => s.play_duration ?? 0).reduce((pv, cv) => pv + cv, 0) ) ); setStashIDs( new ScrapeResult( dest.stash_ids, all .map((s) => s.stash_ids) .flat() .filter((s, index, a) => { // remove entries with duplicate endpoints return index === a.findIndex((ss) => ss.endpoint === s.endpoint); }), !dest.stash_ids.length ) ); loadImages(); }, [sources, dest]); const convertGalleries = useCallback( (ids?: string[]) => { const all = [dest, ...sources]; return ids ?.map((g) => all .map((s) => s.galleries) .flat() .find((gg) => g === gg.id) ) .map((g) => { return { id: g!.id, title: galleryTitle(g!), }; }); }, [dest, sources] ); const originalGalleries = useMemo(() => { return convertGalleries(galleries.originalValue); }, [galleries, convertGalleries]); const newGalleries = useMemo(() => { return convertGalleries(galleries.newValue); }, [galleries, convertGalleries]); // ensure this is updated if fields are changed const hasValues = useMemo(() => { return hasScrapedValues([ title, url, date, rating, oCounter, galleries, studio, performers, movies, tags, details, stashIDs, image, ]); }, [ title, url, date, rating, oCounter, galleries, studio, performers, movies, tags, details, stashIDs, image, ]); function renderScrapeRows() { if (loading) { return (
); } if (!hasValues) { return (
); } return ( <> setTitle(value)} /> setURL(value)} /> setDate(value)} /> ( )} renderNewField={() => ( )} onChange={(value) => setRating(value)} /> ( {}} className="bg-secondary text-white border-secondary" /> )} renderNewField={() => ( {}} className="bg-secondary text-white border-secondary" /> )} onChange={(value) => setOCounter(value)} /> ( {}} className="bg-secondary text-white border-secondary" /> )} renderNewField={() => ( {}} className="bg-secondary text-white border-secondary" /> )} onChange={(value) => setPlayCount(value)} /> ( {}} className="bg-secondary text-white border-secondary" /> )} renderNewField={() => ( {}} className="bg-secondary text-white border-secondary" /> )} onChange={(value) => setPlayDuration(value)} /> ( {}} disabled /> )} renderNewField={() => ( {}} disabled /> )} onChange={(value) => setGalleries(value)} /> setStudio(value)} /> setPerformers(value)} /> setMovies(value)} /> setTags(value)} /> setDetails(value)} /> ( )} renderNewField={() => ( )} onChange={(value) => setStashIDs(value)} /> setImage(value)} /> ); } function createValues(): GQL.SceneUpdateInput { const all = [dest, ...sources]; // only set the cover image if it's different from the existing cover image const coverImage = image.useNewValue ? image.getNewValue() : undefined; return { id: dest.id, title: title.getNewValue(), url: url.getNewValue(), date: date.getNewValue(), rating100: rating.getNewValue(), o_counter: oCounter.getNewValue(), play_count: playCount.getNewValue(), play_duration: playDuration.getNewValue(), gallery_ids: galleries.getNewValue(), studio_id: studio.getNewValue(), performer_ids: performers.getNewValue(), movies: movies.getNewValue()?.map((m) => { // find the equivalent movie in the original scenes const found = all .map((s) => s.movies) .flat() .find((mm) => mm.movie.id === m); return { movie_id: m, scene_index: found!.scene_index, }; }), tag_ids: tags.getNewValue(), details: details.getNewValue(), stash_ids: stashIDs.getNewValue(), cover_image: coverImage, }; } const dialogTitle = intl.formatMessage({ id: "actions.merge", }); const destinationLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.source" }); return ( { if (!apply) { onClose(); } else { onClose(createValues()); } }} /> ); }; interface ISceneMergeModalProps { show: boolean; onClose: (mergedID?: string) => void; scenes: { id: string; title: string }[]; } export const SceneMergeModal: React.FC = ({ show, onClose, scenes, }) => { const [sourceScenes, setSourceScenes] = useState< { id: string; title: string }[] >([]); const [destScene, setDestScene] = useState<{ id: string; title: string }[]>( [] ); const [loadedSources, setLoadedSources] = useState< GQL.SlimSceneDataFragment[] >([]); const [loadedDest, setLoadedDest] = useState(); const [running, setRunning] = useState(false); const [secondStep, setSecondStep] = useState(false); const intl = useIntl(); const Toast = useToast(); const title = intl.formatMessage({ id: "actions.merge", }); useEffect(() => { if (scenes.length > 0) { // set the first scene as the destination, others as source setDestScene([scenes[0]]); if (scenes.length > 1) { setSourceScenes(scenes.slice(1)); } } }, [scenes]); async function loadScenes() { const sceneIDs = sourceScenes.map((s) => parseInt(s.id)); sceneIDs.push(parseInt(destScene[0].id)); const query = await queryFindScenesByID(sceneIDs); const { scenes: loadedScenes } = query.data.findScenes; setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id)); setLoadedSources(loadedScenes.filter((s) => s.id !== destScene[0].id)); setSecondStep(true); } async function onMerge(values: GQL.SceneUpdateInput) { try { setRunning(true); const result = await mutateSceneMerge( destScene[0].id, sourceScenes.map((s) => s.id), values ); if (result.data?.sceneMerge) { Toast.success({ content: intl.formatMessage({ id: "toast.merged_scenes" }), }); // refetch the scene await queryFindScenesByID([parseInt(destScene[0].id)]); onClose(destScene[0].id); } onClose(); } catch (e) { Toast.error(e); } finally { setRunning(false); } } function canMerge() { return sourceScenes.length > 0 && destScene.length !== 0; } function switchScenes() { if (sourceScenes.length && destScene.length) { const newDest = sourceScenes[0]; setSourceScenes([...sourceScenes.slice(1), destScene[0]]); setDestScene([newDest]); } } if (secondStep && destScene.length > 0) { return ( { if (values) { onMerge(values); } else { onClose(); } }} /> ); } return ( loadScenes(), }} disabled={!canMerge()} cancel={{ variant: "secondary", onClick: () => onClose(), }} isRunning={running} >
{FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.source" }), labelProps: { column: true, sm: 3, xl: 12, }, })} setSourceScenes(items)} selected={sourceScenes} /> {FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.destination", }), labelProps: { column: true, sm: 3, xl: 12, }, })} setDestScene(items)} selected={destScene} />
); };